FastAPI Exceptionクラスの例外ハンドリングをする際の挙動
#FastAPI #例外処理
概要
FastAPIでは、API実行時に例外が発生した場合、その例外をキャッチして任意のレスポンスにして返す仕組みがある。
自分はHandling Errors - FastAPIに記載の@app.exception_handlerの仕組みを使って、例外キャッチを行ってたのだが...
Exception例外をキャッチする時だけ、不思議な挙動に出会ったので、そこを解き明かしておく。
ちなみに、不思議な挙動とは...
スタックトレースを出力した覚えはないのに、勝手に出力されるというもの。
これ、Exception以外の例外キャッチした際には出力されないのに、Exceptionの時だけはなぜか出力されます。
先に結論
これはFastAPI、Startletのデフォルト仕様です。
もし挙動を変えたいならカスタムでMiddlewareを作成して登録し、そこでException例外をキャッチするようにすればいいかもです。
参考:FastAPI 独自でエラーハンドリングを設定する方法 - snaqme Engineers Blog
ただ、まあなんだろう。デフォルト仕様は、開発者にとって不明な例外が発生した際に、例外を握りつぶさないように出力するための安全措置な気がしますね。
This allows servers to log the error, or allows test clients to optionally raise the error within the test case.
exception_handlerを利用した例外キャッチの方法
Handling Errors - FastAPIを読んでもろて。
Exception例外もキャッチできるが、違和感のある挙動が見える
思惑として、exception_handlerで独自例外やら具体的な例外をキャッチして例外ハンドリングをしていくつもりなのだが...。
開発時に想定していなかった例外ってあるじゃないですか、そういう例外をまとめてException例外としてキャッチしたいのです。
以下のようにして。
code: app.py
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
app = FastAPI()
@app.exception_handler(Exception)
async def unicorn_exception_handler(request: Request, exc: Exception):
return JSONResponse(
status_code=500
content={"message": f"Internal Error"},
)
@app.get("/unicorns/{name}")
async def read_unicorn(name: str):
if name == "yolo":
raise ValueError
return {"unicorn_name": name}
このコードは実際上手く動きます。
ちゃんとValueErrorをキャッチしてくれる。
レスポンスも500でボディが{"message": "Internal Error"}にもなる。
ただし!
Exception例外をキャッチしたときは、なぜかスタックトレースが出力されてしまいます。
これ自分としてはログが汚れるので、出力させたくないんですが、なぜか出力されてしまいます。
他の例外キャッチの場合には出力されないのに、なぜかExceptionの時だけ。
なぜ「Exception」例外のハンドリングした時だけスタックトレースが出力されるのか
結論.icon FastAPIの内部でExceptionまたは500のハンドリング時だけ、例外キャッチ後にその例外をもう一度投げるっていう処理しているから。
コードを読み解くとまあわかります。
1. exception_handler
code:https://github.com/tiangolo/fastapi/blob/master/fastapi/applications.py#L903
どうやら、exception_handlerで、対象の例外 or ステータスコードとハンドリング関数を内部リストに追加してるようです。
add_exception_handlerとは何か
code:https://github.com/encode/starlette/blob/01aa49a379520d3bbe6bc1aecc48a48169e6b004/starlette/applications.py#L142
これですね、普通に内部のexception_handlersという辞書に登録してるだけのようです。
ちなみに、FastAPIはStartletという別ライブラリの上で成り立っており、この関数はStartletクラスの関数です。FastAPIクラスはStartletクラスのサブクラスです。
2. build_middleware_stack
code:https://github.com/tiangolo/fastapi/blob/master/fastapi/applications.py#L155
まずは以下のコードから。
code: applications.py
error_handler = None
exception_handlers = {}
for key, value in self.exception_handlers.items():
if key in (500, Exception):
error_handler = value
else:
exception_handlerskey = value
Exception or 500で登録されたハンドリング関数は、error_handlerとして登録されるようです。
そして、もしException、500それぞれで登録されてた場合は、最後に登録されたハンドラーが優先されるようですね。
その他のハンドラーはexception_handlersの辞書にぶち込まれると。
次に以下のコード
code: applications.py
middleware = (
Middleware(ServerErrorMiddleware, handler=error_handler, debug=debug)
+ self.user_middleware
+ [
Middleware(
ExceptionMiddleware, handlers=exception_handlers, debug=debug
),
...
Middleware(AsyncExitStackMiddleware),
]
)
error_handlerはServerErrorMiddlewareというミドルウェアで利用されてるようです。
exception_handlerはExceptionMiddlewareというミドルウェアで。
こやつらの内容を見ていきましょう。
3. ExceptionMiddleware
code:https://github.com/encode/starlette/blob/01aa49a379520d3bbe6bc1aecc48a48169e6b004/starlette/middleware/exceptions.py#L53
ざっくり説明すると...
対象の例外をキャッチできるハンドラーが登録されてない場合は、その例外を再度投げるっぽいです。
code: exceptions.py
...
except Exception as exc:
handler = None
if isinstance(exc, HTTPException):
handler = self._status_handlers.get(exc.status_code)
if handler is None:
handler = self._lookup_exception_handler(exc)
if handler is None:
raise exc
...
4. ServerErrorMiddleware
code:https://github.com/encode/starlette/blob/01aa49a379520d3bbe6bc1aecc48a48169e6b004/starlette/middleware/errors.py#L161
ここもざっくり説明すると...
ExceptionMiddlewareにもキャッチされない例外が発生した場合は、その例外をキャッチして、処理するぽいです。
処理内容としては...簡単に以下の感じです。
1. debug=Trueなら、デバッグメッセージをレスポンスで返す。
2. ハンドラーが登録されてないなら、デフォルトのエラーメッセージをレスポンスで返す。
3. ハンドラーが登録されてるなら、そのハンドラー定義のレスポンスを返す。
で、最後が重要。この記事の肝。
code: errors.py
...
# We always continue to raise the exception.
# This allows servers to log the error, or allows test clients
# to optionally raise the error within the test case.
raise exc
これ「いつでも例外投げますよ」と記載があるように、例外をキャッチしてハンドリングしようが何しようが、この中で例外キャッチしたにも関わらず、再度例外が投げられるようになってます。
この仕様が原因で、最終的に投げられた例外をキャッチする人がおらず、そのままスタックトレースとして出力されることになってたって感じです。